Description
A Charm-style CLI that owns DEPLOY + RELEASE β not git push (you keep committing/pushing via lazygit/Claude; deploy is decoupled since 'vercel deploy' uploads the local dir, not the remote). Foreground + inline spinner per stage, so no notification system needed. Reads .custard.yaml for check + deploy config. Verbs: (1) preview β run checks, then vercel deploy (preview), print URL; (2) promote β vercel promote the latest/chosen preview to prod (same build, no rebuild); (3) release vX.Y.Z β run checks, tag + push the tag so the existing brew webhook auto-publishes. Supersedes the earlier server-side-CI sketch in this task's ACs.
Acceptance Criteria
- #1 CLI reads .custard.yaml: ci check commands + deploy (preview/prod) config
- #2 promote: vercel promote latest preview (or 'promote ') β prod, no rebuild
- #3 release vX.Y.Z: run checks, then tag + push tag β existing brew webhook publishes
- #4 Charm UI (Bubble Tea/Lipgloss spinner); distributed via the self-hosted tap (dogfood)
- #5 Does NOT perform git push β lazygit/Claude workflow unaffected
- #6 Multiple previews before promote supported; promote defaults to most recent
- #7 check verb: runs .custard.yaml checks on the working tree (dirty allowed) β fast inner loop, no deploy
- #8 Status reporting: CLI POSTs signed {repo, commit, state(checked|preview|prod), url} to custard
- #9 Forge badges: mark commits β in production / π preview / β checked / β unverified so the live forge shows what's real vs in-flight
- #10 preview: build a clean export of HEAD (git archive/worktree) β run checks β vercel deploy (preview) β print URL; spinner per stage, β + captured output on failure
- #11 Deploys build from the COMMIT, never the working dir β uncommitted edits are structurally excluded (never deployed), and a dirty tree does not block deploying committed work
- #12 preview/promote require HEAD pushed to soft (deployed code == an in-repo commit the forge badge maps to); if unpushed, CLI says push first
- #13 preview/release run checks against the EXPORTED HEAD (committed code); standalone check runs against the working tree β distinct by design
- #14 Single 'custard' binary: 'serve' runs the server (droplet), client verbs (check/preview/promote/release) run locally; refactor cmd/custard into subcommands
- #15 CLI config via ~/.custardrc (custard base URL + status token); vercel auth uses local vercel login
- #16 Server: POST /status (HMAC-signed) persists {repo,commit,state,url} to a writable store (e.g. /var/lib/custard/state); systemd ReadWritePaths includes it
- #17 promote tracks the last preview (local state, e.g. ~/.custard/state.json) or accepts an explicit url; errors clearly if no preview exists
Implementation Notes
Design detail / schema:
.custard.yaml (per repo): brew: { enabled: true, package: "." } # TASK-006 (release) ci: # commands run in order; nonzero exit = fail - go vet ./... - go test ./... deploy: preview: vercel deploy # must print the preview URL on stdout # promote uses: vercel promote (no rebuild)
Verbs (one binary, 'custard '): check run ci on WORKING TREE (dirty ok); no deploy; fast inner loop preview export HEAD (git archiveβtmp) β run ci on export β deploy.preview β capture+print URL β POST /status(preview) promote [url] vercel promote last/ο»Ώgiven preview β prod β POST /status(prod) release vX.Y.Z run ci on HEAD β tag + push tag (TASK-006 webhook publishes brew) β POST /status
Gates: preview/promote do NOT block on a dirty tree (they build from the commit, so uncommitted edits are excluded); they DO require HEAD pushed to soft so deployed==in-repo and the badge maps. check has no gate.
Server additions: POST /status (HMAC, STATUS_SECRET in /etc/custard.env) β persist per (repo,commit); forge reads it to badge commits/branch as: β in production / π preview / β checked / β unverified. State store writable (ReadWritePaths).
Distribution: CLI ships via the self-hosted tap (custard formula) β dogfoods TASK-006. Open: status-store backend (flat json vs sqlite); whether release should also require clean tree.